Day26 量到字串比對的閘門會擋錯、會被繞過;Day27 說明真正的邊界是沙箱,而 Pi 選擇不內建,改成讓你換掉執行環境。
今天實際做一次,量三件事:
Day27 說過,Gondolin 那條路在 Windows 上走不通(Linux micro-VM + QEMU),所以這裡用 Docker:不是把工具路由進容器,而是把整個 pi 程序關進去。
我一開始以為容器裡只要有 Node 和 Pi 就好。錯了。
受測專案的驗收指令是 python scripts/check.py,它會跑 ruff 和 pytest。如果容器裡沒有 Python、沒有 ruff、沒有 pytest,agent 根本無法完成任務——它不是被隔離,是被廢掉。
所以 image 要裝的是「任務需要的東西」,而且版本要跟主機對齊,否則兩組比較的就不只是隔離:
FROM python:3.13-slim-bookworm
ARG NODE_VERSION=24.18.0
ARG PI_VERSION=0.84.3
RUN apt-get install -y ca-certificates curl git xz-utils ripgrep fd-find
RUN curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf "node-v${NODE_VERSION}-linux-x64.tar.xz" -C /usr/local --strip-components=1
RUN npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION}"
# 跟主機同版本,check.py 的行為才一致
RUN pip install --no-cache-dir ruff==0.15.5 pytest==8.3.4
裝 ripgrep 和 fd 也是刻意的:Day13 提過 Pi 第一次啟動會自己去下載這兩個工具。不先裝好,每一次容器執行都會重下載一次。
成品 828 MB。這是隔離的第一筆帳,而且它跟 agent 沒什麼關係——是你的專案的形狀決定的。
容器裡的 Pi 要呼叫模型就需要憑證。Day27 引過官方文件給的兩條路:把 API key 傳進去,或是掛載主機的設定目錄。
我選了掛載,因為這個系列用的是 ChatGPT 帳號的 OAuth 登入,沒有可以單獨傳的 key:
docker run --rm \
-v <run 的工作目錄>:/work \
-v <session 輸出目錄>:/session \
-v <repo>:/repo:ro \
-v D:\pi-agent:/pi-agent \ # ← 主機的 Pi 憑證
-w /work -e PI_CODING_AGENT_DIR=/pi-agent \
pi-bench:0.84.3 \
node /usr/local/lib/.../cli.js -p --mode json --model ... -- "<任務敘述>"
這一行 -v D:\pi-agent:/pi-agent 就是 Day27 那句話的實體:
隔離不是免費的:你關住 agent 的同時,也要把它需要的東西一件一件送進去,而每送一件,邊界就少一分。
後面會回來算這筆帳。
這是我最滿意的部分:量測台幾乎不用改。
條件多一個欄位:
@dataclass(frozen=True)
class Condition:
...
# 把整個 harness 程序放進這個容器跑,而不是主機(Day 28)
docker_image: str | None = None
adapter 裡只多一個分支,而且指令本身一模一樣,只有路徑換掉:
inner = self.build_command(
prompt, condition,
PurePosixPath("/session"), # 主機上是 run_dir/session
cli_js="/usr/local/lib/node_modules/.../cli.js",
repo_root=PurePosixPath("/repo"), # 主機上是 REPO_ROOT
)
return ["docker", "run", "--rm", *mounts, condition.docker_image, *inner]
實驗設定檔就只差一個鍵:
"conditions": [
{"name": "host"},
{"name": "docker", "docker_image": "pi-bench:0.84.3"}
]
這是 Day7 那個「harness 無關的 runner」設計第一次真的回本。 當初把「怎麼叫起一個 agent」抽成 adapter,本來是為了以後能換 harness,結果先換掉的是「在哪裡跑」。
任務用 T2 新增 endpoint,每組 5 次,gpt-5.6-luna,thinking medium。

| 條件 | 成功 | 成本中位數 | tokens 中位數 | 工具呼叫中位數 | 耗時中位數 | 跑了 check.py |
|---|---|---|---|---|---|---|
| 主機 | 5/5 | $0.0073 | 68,417 | 28 | 52s | 5/5 |
| 容器 | 5/5 | $0.0081 | 57,264 | 25 | 55s | 5/5 |
用 Day23 的尺來看:
| 比較 | 差異 | 95% CI(bootstrap) | 有把握? |
|---|---|---|---|
| 成本 | +11.6% | −28.2% ~ +41.9% | 否 |
| 耗時 | +6.4% | −28.8% ~ +26.3% | 否 |
兩個區間都含 0。 加上容器本身的啟動時間我單獨量過——docker run 一個什麼都不做的指令是 0.58 秒,相對於 52 秒的執行根本看不見。
所以第一、二個問題的答案是:
成功率沒掉,成本與時間的差異量不出來。執行期的隔離代價,在這個尺度上接近零。
注意上表一個矛盾的地方:容器組的 tokens 比較少(57K vs 68K),成本卻比較高。
拆開來看就懂了:
| input(原價) | cacheRead(十分之一價) | 合計 | |
|---|---|---|---|
| 主機 | 23,594 | 42,496 | 68,417 |
| 容器 | 26,544 | 33,280 | 57,264 |
容器組的快取命中比較少,所以總 token 少了 16%,帳單反而高一點。Day14 量過 cacheRead 的單價只有 input 的十分之一,這裡就是那件事的後果。
比 token 數不等於比成本。 這也是 Day23 選成本當主指標的原因——它已經把快取折算進去了。
(為什麼容器組快取命中較少?我沒有量到因果。合理的猜測是兩組的工具呼叫序列不同,導致前綴不同;但 n=5,這也可能純粹是隨機。我不打算硬掰。)
這是今天最重要的一段,因為說不清楚邊界的隔離,比沒有隔離更危險。
| 狀態 | |
|---|---|
| 主機的其他檔案(家目錄、其他專案) | ✅ 看不到 |
| 主機安裝的軟體、PATH、環境變數 | ✅ 看不到 |
| 主機的行程 | ✅ 看不到 |
/work(任務目錄) |
❌ 掛進來的,寫入直接落到主機硬碟 |
/repo(本專案) |
❌ 掛進來的,唯讀 |
/pi-agent(我的 Pi 憑證) |
❌ 掛進來的,可讀可寫 |
| 對外網路 | ❌ 預設 bridge,完全暢通 |
| 容器內的身分 | ⚠️ root |
講白話:
一個惡意的 agent 在這個容器裡,仍然可以讀走我的 Pi 憑證,然後用暢通的網路把它送出去。
這不是 Docker 的漏洞,是我為了免登入自己開的門。Day27 那句「每送一件,邊界就少一分」——這就是那一件。
要把門關小,有幾個現成做法,但每一個都有代價:
| 做法 | 代價 |
|---|---|
| 改用短期 API key,不掛整個憑證目錄 | 這個系列用 OAuth 登入,沒有可單獨傳的 key |
/pi-agent 掛成唯讀 |
token 過期時無法自動更新 |
--network 限制只能連模型端點 |
要維護一份端點清單 |
--read-only rootfs + tmpfs |
要先確認 Pi 的暫存寫在哪 |
| 不要用 root | 掛進來的 Windows 目錄權限要另外處理 |
我這次都沒做,因為今天的目的是量「同一件事在兩個地方跑的差別」,多加一項就多一個變數。但如果是真的要讓 agent 自動跑在 CI 上,這張表就是待辦清單。
一、執行期的隔離幾乎免費,建置期不是。
828 MB 的 image、跟主機對齊的四個工具版本、一份憑證的處置決定——這些才是成本,而且它們是一次性的、可以被寫進 Dockerfile 的。
二、容器裡要裝的是任務的相依,不是 agent 的相依。
這件事很容易漏。agent 只需要 Node;但沒有 ruff 和 pytest,它連「我做完了嗎」都回答不了。
三、邊界要能一條一條列出來。
上面那張表才是這篇的產出。「我有用 Docker」不是安全宣稱;「/pi-agent 是可讀可寫掛進去的、網路暢通、容器內是 root」才是。
Dockerfile 與完整資料:docker/Dockerfile、experiments/results/e28_sandbox/。
Day29 回頭講一件從 Day18 就一直在用、但我從來沒認真介紹過的東西:Pi 的 session 檔。它不是對話存檔,而是一份可以重新 fold 成狀態的事件記錄——而這個性質,讓我在最後一週抓到了一個自己的量測 bug,還不用重跑任何一次實驗。